#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
ESP32 APRS 串口配置工具（Windows）
依赖：pip install pyserial

中文界面与日志，避免乱码：
- 本文件使用 UTF-8 保存
- Windows 上优先使用 “Microsoft YaHei UI” 字体
- 串口读写统一使用 UTF-8（decode errors="ignore" 容错）
"""

import tkinter as tk
from tkinter import ttk, messagebox
from tkinter import scrolledtext
from typing import Optional
import serial, serial.tools.list_ports, time, re, threading

# 品牌与版本
APP_BRAND = "BD3ND"
APP_VERSION = "v1.0.0"

BAUD = 115200

# ---------- 串口辅助 ----------

def list_ports():
    return [p.device for p in serial.tools.list_ports.comports()]

def open_port(port: str, baud: int = BAUD, timeout: float = 1.0, write_timeout: float = 1.0) -> serial.Serial:
    """以安全方式打开串口，避免进入下载模式（DTR/RTS 预置）。"""
    s = serial.Serial()
    s.port = port
    s.baudrate = baud
    s.timeout = timeout
    s.write_timeout = write_timeout
    s.rtscts = False
    s.dsrdtr = False
    try:
        s.dtr = False
        s.rts = False
    except Exception:
        pass
    s.open()
    try:
        s.setRTS(False)
        time.sleep(0.05)
        s.setDTR(True)
    except Exception:
        pass
    time.sleep(0.25)
    try:
        s.reset_input_buffer(); s.reset_output_buffer()
    except Exception:
        pass
    return s

def send_cmd(s: serial.Serial, cmd: str):
    if not cmd.endswith("\n"):
        cmd += "\n"
    s.write(cmd.encode("utf-8", errors="ignore")); s.flush()

def read_config_text(s: serial.Serial, retries: int = 10, wait_before: float = 0.4, wait_after: float = 0.35) -> str:
    """收集启动打印，然后多次发送 show 获取配置。"""
    buf = ""
    time.sleep(wait_before)
    try:
        boot = s.read_all().decode(errors="ignore")
        if boot:
            buf += boot
    except Exception:
        pass
    try:
        # 先发送识别命令，固件会回复 "[ID] ESP32_APRS"
        s.write(b"id\n"); s.flush()
    except Exception:
        pass
    tokens = ("callsign:", "APRS-IS", "Symbol", "ESP32_APRS")
    for _ in range(retries):
        try:
            send_cmd(s, "show")
        except Exception:
            time.sleep(0.2)
        time.sleep(wait_after)
        try:
            resp = s.read_all().decode(errors="ignore")
            if resp:
                buf += "\n" + resp
        except Exception:
            pass
        if any(t in buf for t in tokens):
            break
    return buf

# ---------- Callsign 规范化与 Passcode 计算 ----------

def sanitize_callsign(text: str) -> str:
    """将呼号转为大写并过滤非法字符（只保留 A-Z/0-9 和一个连字符-）。"""
    if not text:
        return ""
    t = text.upper()
    res = []
    hyphen = False
    for ch in t:
        if 'A' <= ch <= 'Z' or '0' <= ch <= '9':
            res.append(ch)
        elif ch == '-' and not hyphen:
            res.append('-'); hyphen = True
    return ''.join(res)

def calc_aprs_passcode(callsign: str) -> int:
    """按 APRS-IS 算法计算校验码（与固件一致）。"""
    if not callsign:
        return 0
    root = sanitize_callsign(callsign)
    dash = root.find('-')
    if dash > 0:
        root = root[:dash]
    root = root.upper()
    hi, lo = 0x73, 0xE2
    i = 0
    while i < len(root):
        c1 = ord(root[i])
        c2 = ord(root[i+1]) if i+1 < len(root) else 0
        hi ^= c1
        lo ^= c2
        i += 2
    return ((hi & 0x7F) << 8) | lo

# ---------- 解析设备打印 ----------

def parse_config(text: str) -> dict:
    cfg = {}
    m = re.search(r"callsign:\s*([^\s]+)\s+\(pass:\s*(\d+)\)", text)
    if m:
        cfg["callsign"], cfg["passcode"] = m.group(1), m.group(2)
    m = re.search(r"APRS-IS\s*:\s*([^\s:]+):(\d+)", text)
    if m: cfg["aprs_host"], cfg["aprs_port"] = m.group(1), m.group(2)
    m = re.search(r"WiFi\s*:\s*([^/]+)/\s*(\S+)", text)
    if m: cfg["wifi_ssid"], cfg["wifi_pass"] = m.group(1).strip(), m.group(2).strip()
    m = re.search(r"Symbol\s*:\s*(.?)\s*(.?)", text)
    if m: cfg["sym_table"], cfg["sym_char"] = m.group(1), m.group(2)
    m = re.search(r"Beacon\s*:\s*(\d+)\s*sec", text)
    if m: cfg["beacon"] = m.group(1)
    m = re.search(r"Status\s*:\s*(.*)", text)
    if m: cfg["status"] = m.group(1).strip()
    return cfg

# ---------- 图形界面 ----------

class App(tk.Tk):
    def __init__(self):
        super().__init__()
        self.title(f"ESP32 APRS 配置 - {APP_BRAND} {APP_VERSION}")
        self.geometry("900x620")
        self.resizable(False, False)
        self.s: Optional[serial.Serial] = None
        self.port_var = tk.StringVar()
        self.status_var = tk.StringVar(value="未连接")
        self._build_ui()
        self.refresh_ports()
        # 状态动画（省略号）
        try: self.after(250, self._auto_spin_tick)
        except Exception: pass

    def _build_ui(self):
        style = ttk.Style()
        try: style.theme_use("clam")
        except Exception: pass
        try:
            style.configure("TLabel", padding=(2,2), font=("Microsoft YaHei UI", 9))
            style.configure("TEntry", padding=(2,2), font=("Microsoft YaHei UI", 9))
            style.configure("TButton", padding=(4,4), font=("Microsoft YaHei UI", 9))
        except Exception:
            style.configure("TLabel", padding=(2,2))
            style.configure("TEntry", padding=(2,2))
            style.configure("TButton", padding=(4,4))

        top = ttk.Frame(self, padding=10); top.pack(fill="x")
        ttk.Label(top, text="串口:").grid(row=0, column=0, sticky="e")
        self.cmb = ttk.Combobox(top, textvariable=self.port_var, width=32, state="readonly")
        self.cmb.grid(row=0, column=1, sticky="w")
        ttk.Button(top, text="刷新", command=self.refresh_ports).grid(row=0, column=2, padx=6)
        ttk.Button(top, text="连接", command=self.connect_selected).grid(row=0, column=3)
        ttk.Button(top, text="自动连接", command=self.do_autoconnect).grid(row=0, column=4, padx=6)
        # 连接状态指示灯（绿=已连接，灰=未连接，橙=忙）
        self.led = tk.Canvas(top, width=16, height=16, highlightthickness=0)
        self.led.grid(row=0, column=5, padx=(8,0))
        self._led = self.led.create_oval(2,2,14,14, fill="#bdbdbd", outline="#888")
        # 右侧品牌与版本
        try:
            top.grid_columnconfigure(6, weight=1)
        except Exception:
            pass
        brand = ttk.Frame(top)
        brand.grid(row=0, column=7, sticky="e")
        self.brand_lbl = tk.Label(brand, text=APP_BRAND, fg="#1e88e5", font=("Microsoft YaHei UI", 12, "bold"))
        self.brand_lbl.pack(side="top", anchor="e")
        self.ver_lbl = ttk.Label(brand, text=f"版本 {APP_VERSION}", foreground="#666")
        self.ver_lbl.pack(side="top", anchor="e")

        ttk.Separator(self, orient="horizontal").pack(fill="x", padx=10, pady=6)

        form = ttk.LabelFrame(self, text="配置", padding=10); form.pack(fill="x", padx=10)
        r=0
        self.e_call      = self._add_row(form, r, "呼号 (CALL-SSID):"); r+=1
        ttk.Label(form, text="呼号校验码:", width=28, anchor="e").grid(row=r, column=0, sticky="e", pady=2)
        self.e_pass = ttk.Entry(form, width=56, state="readonly")
        self.e_pass.grid(row=r, column=1, sticky="w")
        r+=1
        self.e_wifi_ssid = self._add_row(form, r, "WiFi 名称:"); r+=1
        self.e_wifi_pass = self._add_row(form, r, "WiFi 密码:"); r+=1
        self.e_host      = self._add_row(form, r, "APRS 服务器:"); r+=1
        self.e_port      = self._add_row(form, r, "APRS 端口:"); r+=1
        self.e_table     = self._add_row(form, r, "符号表 (/ 或 \\):"); r+=1
        self.e_symbol    = self._add_row(form, r, "符号字符:"); r+=1
        self.e_interval  = self._add_row(form, r, "信标间隔(秒):"); r+=1
        self.e_comment   = self._add_row(form, r, "状态文本(注释):"); r+=1

        btns = ttk.Frame(self, padding=10); btns.pack(fill="x")
        ttk.Button(btns, text="读取", command=self.read_cfg).pack(side="left")
        ttk.Button(btns, text="写入并保存", command=self.write_and_save).pack(side="left", padx=10)
        ttk.Button(btns, text="清空日志", command=self.clear_log).pack(side="left")
        ttk.Button(btns, text="复制日志", command=self.copy_log).pack(side="left", padx=10)

        log = ttk.LabelFrame(self, text="日志 / 状态", padding=6); log.pack(fill="both", expand=True, padx=10, pady=(0,6))
        self.msg = scrolledtext.ScrolledText(log, height=20, width=120); self.msg.pack(fill="both", expand=True)

        status = ttk.Frame(self, padding=(10,0,10,10)); status.pack(fill="x")
        ttk.Label(status, textvariable=self.status_var, foreground="#555").pack(side="left")

        # 呼号输入联动：自动转大写并更新校验码
        self.e_call.bind('<KeyRelease>', lambda e: self.update_passcode(auto_fix=True))
        self.e_call.bind('<FocusOut>',  lambda e: self.update_passcode(auto_fix=True))

    def _add_row(self, parent, row, label):
        ttk.Label(parent, text=label, width=28, anchor="e").grid(row=row, column=0, sticky="e", pady=2)
        ent = ttk.Entry(parent, width=56); ent.grid(row=row, column=1, sticky="w"); return ent

    # ----- helpers -----
    def append_log(self, text):
        ts = time.strftime("%H:%M:%S"); self.msg.insert("end", f"[{ts}] {text}\n"); self.msg.see("end")
    def set_status(self, text):
        self.status_var.set(text); self.append_log(text)
    def _set_status_quiet(self, text):
        self.status_var.set(text)

    def _set_pass_entry(self, value: str):
        try:
            self.e_pass.configure(state='normal')
            self.e_pass.delete(0,'end')
            self.e_pass.insert(0, value)
        finally:
            self.e_pass.configure(state='readonly')

    def clear_log(self):
        """清空日志窗口。"""
        try:
            self.msg.delete("1.0", "end")
        except Exception:
            pass

    def copy_log(self):
        """复制日志到剪贴板。"""
        try:
            self.clipboard_clear()
            self.clipboard_append(self.msg.get("1.0", "end"))
            self.set_status("已复制日志")
        except Exception:
            pass

    def update_passcode(self, auto_fix: bool = False):
        call = self.e_call.get().strip()
        if auto_fix:
            fixed = sanitize_callsign(call)
            if fixed != call:
                self.e_call.delete(0,'end'); self.e_call.insert(0, fixed)
            call = fixed
        pc = calc_aprs_passcode(call) if call else 0
        self._set_pass_entry(str(pc))

    def _auto_spin_tick(self):
        """若状态栏显示为“自动连接/正在连接/读取配置”，自动追加省略号动画（不刷日志）。"""
        try:
            text = self.status_var.get()
            m = re.match(r"^(自动连接|正在连接|读取配置)(\.*)$", text)
            if m:
                base = m.group(1)
                dots = m.group(2)
                nd = (len(dots) % 6) + 1
                self._set_status_quiet(base + ("." * nd))
        finally:
            try: self.after(250, self._auto_spin_tick)
            except Exception: pass

    # ----- actions -----
    def refresh_ports(self):
        ports = list_ports(); self.cmb["values"] = ports
        if ports: self.port_var.set(ports[0])
        self.set_status("串口: " + ", ".join(ports) if ports else "未发现串口")
        if not ports:
            self.set_connected(False)

    def set_connected(self, ok: bool, port: Optional[str] = None):
        color = "#4CAF50" if ok else "#bdbdbd"
        try: self.led.itemconfig(self._led, fill=color)
        except Exception: pass
        if ok:
            msg = f"已连接 {port}" if port else "已连接"
            self.status_var.set(msg); self.append_log(msg)
        else:
            self.status_var.set("未连接"); self.append_log("未连接")

    def connect_selected(self):
        p = self.port_var.get().strip()
        if not p: messagebox.showerror("错误","请选择串口"); return
        try:
            if self.s and self.s.is_open:
                try: self.s.close()
                except Exception: pass
            self.led.itemconfig(self._led, fill="#FFA726")  # 正在连接（橙色）
            self.set_status("正在连接...")
            self.s = open_port(p, BAUD)
            self.set_connected(True, f"{p}@{BAUD}")
            self.read_cfg()
        except Exception as e:
            messagebox.showerror("错误", f"连接失败: {e}"); self.set_status(f"连接失败: {e}"); self.set_connected(False)

    def do_autoconnect(self):
        self.set_status("自动连接...")
        def worker():
            ports = list_ports()
            if not ports:
                self.after(0, lambda: self.set_status("未发现串口")); return
            def quick_probe(p: str) -> bool:
                try:
                    with open_port(p, BAUD, timeout=0.25, write_timeout=0.25) as s:
                        s.reset_input_buffer(); s.reset_output_buffer()
                        try: s.write(b"id\n"); s.flush()
                        except Exception: pass
                        time.sleep(0.18)
                        data = ""
                        try: data = s.read_all().decode(errors="ignore")
                        except Exception: pass
                        if "ESP32_APRS" in data:
                            return True
                        try: s.write(b"show\n"); s.flush()
                        except Exception: pass
                        time.sleep(0.28)
                        try: data += s.read_all().decode(errors="ignore")
                        except Exception: pass
                        return ("APRS-IS" in data) or ("Symbol" in data) or ("callsign" in data)
                except Exception:
                    return False
            for p in ports:
                if quick_probe(p):
                    self.after(0, lambda: self.port_var.set(p))
                    self.after(150, self.connect_selected)
                    return
                else:
                    self.after(0, lambda p=p: self.append_log(f"探测 {p}: 无响应"))
            self.after(0, lambda: self.set_status("未找到设备"))
        threading.Thread(target=worker, daemon=True).start()

    def ensure_open(self) -> bool:
        if self.s and self.s.is_open: return True
        self.connect_selected(); return bool(self.s and self.s.is_open)

    def read_cfg(self):
        if not self.ensure_open(): return
        self.set_status("读取配置...")
        def worker():
            try:
                txt = read_config_text(self.s, retries=8, wait_before=0.8, wait_after=0.6)
                cfg = parse_config(txt)
                def fill():
                    self.msg.insert("end", "设备输出:\n" + (txt or "(无)") + "\n")
                    if 'callsign' in cfg: self.e_call.delete(0,'end'); self.e_call.insert(0,cfg['callsign'])
                    if 'passcode' in cfg: self._set_pass_entry(cfg['passcode'])
                    else: self.update_passcode()
                    if 'wifi_ssid' in cfg: self.e_wifi_ssid.delete(0,'end'); self.e_wifi_ssid.insert(0,cfg['wifi_ssid'])
                    if 'wifi_pass' in cfg: self.e_wifi_pass.delete(0,'end'); self.e_wifi_pass.insert(0,cfg['wifi_pass'])
                    if 'aprs_host' in cfg: self.e_host.delete(0,'end'); self.e_host.insert(0,cfg['aprs_host'])
                    if 'aprs_port' in cfg: self.e_port.delete(0,'end'); self.e_port.insert(0,cfg['aprs_port'])
                    if 'sym_table' in cfg: self.e_table.delete(0,'end'); self.e_table.insert(0,cfg['sym_table'])
                    if 'sym_char' in cfg: self.e_symbol.delete(0,'end'); self.e_symbol.insert(0,cfg['sym_char'])
                    if 'beacon' in cfg: self.e_interval.delete(0,'end'); self.e_interval.insert(0,cfg['beacon'])
                    if 'status' in cfg: self.e_comment.delete(0,'end'); self.e_comment.insert(0,cfg['status'])
                    self.set_status("读取完成" if cfg else "未解析到配置；请先“写入并保存”，再点击“读取”")
                self.after(0, fill)
            except Exception as e:
                self.after(0, lambda: messagebox.showerror('错误', f'读取失败: {e}'))
                self.after(0, lambda: self.set_status(f'读取失败: {e}'))
        threading.Thread(target=worker, daemon=True).start()


    def write_and_save(self):
        if not self.ensure_open():
            return
        self.set_status("写入并保存...")

        def worker():
            try:
                cmds = []
                call = sanitize_callsign(self.e_call.get().strip())
                if call:
                    self.e_call.delete(0, 'end'); self.e_call.insert(0, call)
                    cmds.append(f"set CALLSIGN {call}")
                ssid = self.e_wifi_ssid.get().strip(); pw = self.e_wifi_pass.get().strip()
                if ssid:
                    cmds.append(f"set WIFI {ssid} {pw}")
                host = self.e_host.get().strip(); port = self.e_port.get().strip()
                if host and port:
                    cmds.append(f"set APRS {host} {port}")
                tbl = self.e_table.get().strip(); sym = self.e_symbol.get().strip()
                if tbl in ['/', '\\'] and sym:
                    cmds.append(f"set SYMBOL {tbl} {sym[0]}")
                sec = self.e_interval.get().strip()
                if sec:
                    cmds.append(f"set INTERVAL {sec}")
                cm = self.e_comment.get().strip()
                if cm:
                    cmds.append(f"set COMMENT {cm}")
                cmds.append('save')

                for c in cmds:
                    self.after(0, lambda c=c: self.append_log("发送: " + c))
                    send_cmd(self.s, c)
                    time.sleep(0.18)

                port_name = self.s.port
                self.after(0, lambda: self.append_log("发送: reboot"))
                try:
                    send_cmd(self.s, "reboot")
                except Exception as e:
                    self.after(0, lambda e=e: self.append_log(f"发送 reboot 失败: {e}"))
                time.sleep(0.3)

                try:
                    self.s.close()
                except Exception:
                    pass

                reopened = False
                new_serial = None
                for _ in range(20):
                    try:
                        time.sleep(0.5)
                        new_serial = open_port(port_name)
                        reopened = True
                        break
                    except Exception:
                        continue

                if not reopened or new_serial is None:
                    self.after(0, lambda: self.set_status("重启后串口未重新连接，请手动重连。"))
                    return

                self.s = new_serial
                self.after(0, lambda: self.set_connected(True, port_name))
                self.after(0, lambda: self.append_log("设备已重启，读取新配置..."))
                time.sleep(0.3)
                self.after(0, self.read_cfg)
                self.after(0, lambda: self.set_status("写入并加载完成"))
            except Exception as e:
                self.after(0, lambda: messagebox.showerror('错误', f'写入失败: {e}'))
                self.after(0, lambda: self.set_status(f'写入失败: {e}'))

        threading.Thread(target=worker, daemon=True).start()

    def destroy(self):
        # 退出前关闭串口
        try:
            if self.s and self.s.is_open:
                self.s.close()
        except Exception:
            pass
        super().destroy()

if __name__ == "__main__":
    App().mainloop()
